import {
  AnimationClip,
  Bone,
  FileLoader,
  Loader,
  Quaternion,
  QuaternionKeyframeTrack,
  Skeleton,
  Vector3,
  VectorKeyframeTrack
} from '../../three.module.js';

/**
 * Description: reads BVH files and outputs a single Skeleton and an AnimationClip
 *
 * Currently only supports bvh files containing a single root.
 *
 */

class BVHLoader extends Loader {

  constructor(manager) {

    super(manager);

    this.animateBonePositions = true;
    this.animateBoneRotations = true;

  }

  load(url, onLoad, onProgress, onError) {

    const scope = this;

    const loader = new FileLoader(scope.manager);
    loader.setPath(scope.path);
    loader.setRequestHeader(scope.requestHeader);
    loader.setWithCredentials(scope.withCredentials);
    loader.load(url, function (text) {

      try {

        onLoad(scope.parse(text));

      } catch (e) {

        if (onError) {

          onError(e);

        } else {

          console.error(e);

        }

        scope.manager.itemError(url);

      }

    }, onProgress, onError);

  }

  parse(text) {

    /*
      reads a string array (lines) from a BVH file
      and outputs a skeleton structure including motion data

      returns thee root node:
      { name: '', channels: [], children: [] }
    */
    function readBvh(lines) {

      // read model structure

      if (nextLine(lines) !== 'HIERARCHY') {

        console.error('THREE.BVHLoader: HIERARCHY expected.');

      }

      const list = []; // collects flat array of all bones
      const root = readNode(lines, nextLine(lines), list);

      // read motion data

      if (nextLine(lines) !== 'MOTION') {

        console.error('THREE.BVHLoader: MOTION expected.');

      }

      // number of frames

      let tokens = nextLine(lines).split(/[\s]+/);
      const numFrames = parseInt(tokens[1]);

      if (isNaN(numFrames)) {

        console.error('THREE.BVHLoader: Failed to read number of frames.');

      }

      // frame time

      tokens = nextLine(lines).split(/[\s]+/);
      const frameTime = parseFloat(tokens[2]);

      if (isNaN(frameTime)) {

        console.error('THREE.BVHLoader: Failed to read frame time.');

      }

      // read frame data line by line

      for (let i = 0; i < numFrames; i++) {

        tokens = nextLine(lines).split(/[\s]+/);
        readFrameData(tokens, i * frameTime, root);

      }

      return list;

    }

    /*
      Recursively reads data from a single frame into the bone hierarchy.
      The passed bone hierarchy has to be structured in the same order as the BVH file.
      keyframe data is stored in bone.frames.

      - data: splitted string array (frame values), values are shift()ed so
      this should be empty after parsing the whole hierarchy.
      - frameTime: playback time for this keyframe.
      - bone: the bone to read frame data from.
    */
    function readFrameData(data, frameTime, bone) {

      // end sites have no motion data

      if (bone.type === 'ENDSITE') return;

      // add keyframe

      const keyframe = {
        time: frameTime,
        position: new Vector3(),
        rotation: new Quaternion()
      };

      bone.frames.push(keyframe);

      const quat = new Quaternion();

      const vx = new Vector3(1, 0, 0);
      const vy = new Vector3(0, 1, 0);
      const vz = new Vector3(0, 0, 1);

      // parse values for each channel in node

      for (let i = 0; i < bone.channels.length; i++) {

        switch (bone.channels[i]) {

          case 'Xposition':
            keyframe.position.x = parseFloat(data.shift().trim());
            break;
          case 'Yposition':
            keyframe.position.y = parseFloat(data.shift().trim());
            break;
          case 'Zposition':
            keyframe.position.z = parseFloat(data.shift().trim());
            break;
          case 'Xrotation':
            quat.setFromAxisAngle(vx, parseFloat(data.shift().trim()) * Math.PI / 180);
            keyframe.rotation.multiply(quat);
            break;
          case 'Yrotation':
            quat.setFromAxisAngle(vy, parseFloat(data.shift().trim()) * Math.PI / 180);
            keyframe.rotation.multiply(quat);
            break;
          case 'Zrotation':
            quat.setFromAxisAngle(vz, parseFloat(data.shift().trim()) * Math.PI / 180);
            keyframe.rotation.multiply(quat);
            break;
          default:
            console.warn('THREE.BVHLoader: Invalid channel type.');

        }

      }

      // parse child nodes

      for (let i = 0; i < bone.children.length; i++) {

        readFrameData(data, frameTime, bone.children[i]);

      }

    }

    /*
     Recursively parses the HIERACHY section of the BVH file

     - lines: all lines of the file. lines are consumed as we go along.
     - firstline: line containing the node type and name e.g. 'JOINT hip'
     - list: collects a flat list of nodes

     returns: a BVH node including children
    */
    function readNode(lines, firstline, list) {

      const node = {name: '', type: '', frames: []};
      list.push(node);

      // parse node type and name

      let tokens = firstline.split(/[\s]+/);

      if (tokens[0].toUpperCase() === 'END' && tokens[1].toUpperCase() === 'SITE') {

        node.type = 'ENDSITE';
        node.name = 'ENDSITE'; // bvh end sites have no name

      } else {

        node.name = tokens[1];
        node.type = tokens[0].toUpperCase();

      }

      if (nextLine(lines) !== '{') {

        console.error('THREE.BVHLoader: Expected opening { after type & name');

      }

      // parse OFFSET

      tokens = nextLine(lines).split(/[\s]+/);

      if (tokens[0] !== 'OFFSET') {

        console.error('THREE.BVHLoader: Expected OFFSET but got: ' + tokens[0]);

      }

      if (tokens.length !== 4) {

        console.error('THREE.BVHLoader: Invalid number of values for OFFSET.');

      }

      const offset = new Vector3(
        parseFloat(tokens[1]),
        parseFloat(tokens[2]),
        parseFloat(tokens[3])
      );

      if (isNaN(offset.x) || isNaN(offset.y) || isNaN(offset.z)) {

        console.error('THREE.BVHLoader: Invalid values of OFFSET.');

      }

      node.offset = offset;

      // parse CHANNELS definitions

      if (node.type !== 'ENDSITE') {

        tokens = nextLine(lines).split(/[\s]+/);

        if (tokens[0] !== 'CHANNELS') {

          console.error('THREE.BVHLoader: Expected CHANNELS definition.');

        }

        const numChannels = parseInt(tokens[1]);
        node.channels = tokens.splice(2, numChannels);
        node.children = [];

      }

      // read children

      while (true) {

        const line = nextLine(lines);

        if (line === '}') {

          return node;

        } else {

          node.children.push(readNode(lines, line, list));

        }

      }

    }

    /*
      recursively converts the internal bvh node structure to a Bone hierarchy

      source: the bvh root node
      list: pass an empty array, collects a flat list of all converted THREE.Bones

      returns the root Bone
    */
    function toTHREEBone(source, list) {

      const bone = new Bone();
      list.push(bone);

      bone.position.add(source.offset);
      bone.name = source.name;

      if (source.type !== 'ENDSITE') {

        for (let i = 0; i < source.children.length; i++) {

          bone.add(toTHREEBone(source.children[i], list));

        }

      }

      return bone;

    }

    /*
      builds a AnimationClip from the keyframe data saved in each bone.

      bone: bvh root node

      returns: a AnimationClip containing position and quaternion tracks
    */
    function toTHREEAnimation(bones) {

      const tracks = [];

      // create a position and quaternion animation track for each node

      for (let i = 0; i < bones.length; i++) {

        const bone = bones[i];

        if (bone.type === 'ENDSITE')
          continue;

        // track data

        const times = [];
        const positions = [];
        const rotations = [];

        for (let j = 0; j < bone.frames.length; j++) {

          const frame = bone.frames[j];

          times.push(frame.time);

          // the animation system animates the position property,
          // so we have to add the joint offset to all values

          positions.push(frame.position.x + bone.offset.x);
          positions.push(frame.position.y + bone.offset.y);
          positions.push(frame.position.z + bone.offset.z);

          rotations.push(frame.rotation.x);
          rotations.push(frame.rotation.y);
          rotations.push(frame.rotation.z);
          rotations.push(frame.rotation.w);

        }

        if (scope.animateBonePositions) {

          tracks.push(new VectorKeyframeTrack('.bones[' + bone.name + '].position', times, positions));

        }

        if (scope.animateBoneRotations) {

          tracks.push(new QuaternionKeyframeTrack('.bones[' + bone.name + '].quaternion', times, rotations));

        }

      }

      return new AnimationClip('animation', -1, tracks);

    }

    /*
      returns the next non-empty line in lines
    */
    function nextLine(lines) {

      let line;
      // skip empty lines
      while ((line = lines.shift().trim()).length === 0) {
      }

      return line;

    }

    const scope = this;

    const lines = text.split(/[\r\n]+/g);

    const bones = readBvh(lines);

    const threeBones = [];
    toTHREEBone(bones[0], threeBones);

    const threeClip = toTHREEAnimation(bones);

    return {
      skeleton: new Skeleton(threeBones),
      clip: threeClip
    };

  }

}

export {BVHLoader};
